本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2022年10月05日 统计字数: 16669字 阅读时间: 34分钟阅读 本文链接: https://soulteary.com/2022/10/05/nginx-black-magic-low-cost-high-performance-applications-using-ngx-php-modules.html ----- # Nginx 黑魔法:使用 NGX-PHP 模块低成本实现高性能应用 本篇文章分享一个和 Nginx 以及 PHP 有关的“黑魔法”:NGX-PHP 模块。通过这个方式,我们可以低成本的实现高性能应用,以及适合在服务器资源有限的情况下,同时体验到 Nginx 的高效以及 PHP 的灵活。 如果你对 PHP 的印象还停留在“慢”,那么或许这篇文章可以帮助你打开新世界。 ## 写在前面 提到 “NGX 和 PHP”,使用过 Nginx 和 PHP 的同学第一反映可能是 **Nginx + PHP-FPM** 这种架构。不过,这篇文章中,我们要提到的技术架构更简单高效一些:直接使用 Nginx 和三方模块(NGX-PHP),调用 PHP Embedded 库,来实现原本需要跨进程实现的功能,从而明显提升应用性能。 之所以能够这样玩,需要感谢下面两个项目的相关实现: - PHP 提供了一种有趣的调用方式:让其他的程序能够通过支持 C Bindings 的符号绑定的方式来调用它的核心引擎,Zend。这种接口调用方式,被称作 PHP SAPI 或者 PHP-Embeded,项目地址:[https://github.com/php/php-src/tree/master/sapi/embed](https://github.com/php/php-src/tree/master/sapi/embed) - 2016 年,有一位来自搜狐的工程师 rryqszq4,开始在 GitHub 上尝试开源一个项目,把 “Nginx” 和 “PHP-Embeded Library” 桥接到一起,这个项目经过多年发展,陆续支持了 PHP5、PHP7,以及最新的 PHP8。项目地址:[https://github.com/rryqszq4/ngx-php](https://github.com/rryqszq4/ngx-php) 在 Techem Power 的测试中,自 2020 年开始,“NGX-PHP” 这个技术选型出现之后,便取得了不错的成绩,比如:2020 年的[Round 19](https://www.techempower.com/benchmarks/#section=data-r19&test=composite),以及 2022 年的[Round 21](https://www.techempower.com/benchmarks/#section=data-r21&test=composite)。 ![2020 年和 2022 年的两轮框架评分测试](https://attachment.soulteary.com/2022/10/05/techempower-ranking.jpg) 在最近的 2022 年测试中,[框架开销](https://www.techempower.com/benchmarks/#section=data-r21&test=db)非常低,位于排行榜第五和第六名。 ![2022 年测试中,框架开销排行](https://attachment.soulteary.com/2022/10/05/r21-fw-overhead.jpg) 如果用我们熟悉的 Node.js + MongoDB 作为基准,那么这套方案开销比它少 300%:跑的快,吃草少。换个角度来看,这个方案非常贴合 “Nginx” 和 “PHP” 的特性:快糙猛。 好了,关于这个项目的概况就介绍到这里,我们先来使用 Docker 快速、实际的感受下它的性能。 ## 快速体验 执行下面的命令: ```bash docker run --rm -it -v `pwd`/data:/usr/share/nginx/html/data:rw -p 8090:80 soulteary/ngx-php:8-microblog ``` 当 Docker 镜像下载完毕之后,我们将看到一个和普通 Nginx 镜像启动无异的日志输出: ```bash /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/ /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh /docker-entrypoint.sh: Configuration complete; ready for start up 2022/10/05 10:25:39 [notice] 1#1: using the "epoll" event method 2022/10/05 10:25:39 [notice] 1#1: nginx/1.23.1 2022/10/05 10:25:39 [notice] 1#1: built by gcc 11.2.1 20220219 (Alpine 11.2.1_git20220219) 2022/10/05 10:25:39 [notice] 1#1: OS: Linux 5.10.76-linuxkit ... ``` 打开浏览器,输入 `http://localhost:8090` ,就能够看到效果啦。 ![2022 年测试中,框架开销排行](https://attachment.soulteary.com/2022/10/05/demo-webui.jpg) 随手输入一些内容,能够看到程序“跑的”还是挺快的。 ![发一些只有自己看的到的“微博”](https://attachment.soulteary.com/2022/10/05/final.png) 在不进行应用优化、Nginx 优化的前提下,我们能够看到处理一个请求不过 2ms 左右。 ![单次请求服务端处理时间2ms左右](https://attachment.soulteary.com/2022/10/05/per-req-2ms-costs.jpg) 接下来,我们来聊聊如何使用 NGX-PHP,学习了解这种开源方案背后的一些细节。完整的应用代码,我上传到了 [soulteary/ngx-php-micro-blog](https://github.com/soulteary/ngx-php-micro-blog),有需要可以自取。 ## 准备工作 想要愉快的阅读和跟着本文游玩,只需要 Docker 环境,可以参考《[在笔记本上搭建高性价比的 Linux 学习环境:基础篇](https://soulteary.com/2022/06/21/building-a-cost-effective-linux-learning-environment-on-a-laptop-the-basics.html)》文章完成基础环境的准备,就不过多赘述了。 ## 实现简单的微博应用 我们来使用“最好的语言:PHP”,实现一个简单的“微博/推特”程序。 ### 简单实现模版类 使用 PHP “画一个”页面出来,可以用的方式非常多,最具可维护性的方式是使用”“模版”。为了不过多引入复杂性,就不使用 PHP 包管理器来为项目添加“模版引擎”了,我们来实现一个简单的模版类(不到 30 行): ```php dir = $dir; } } public function render($file) { if (file_exists($this->dir . $file)) { include $this->dir . $file; } else { throw new Exception('no template file ' . $file . ' present in directory ' . $this->dir); } } public function __set($name, $value) { $this->vars[$name] = $value; } public function __get($name) { return $this->vars[$name]; } } ``` 在完成简单的模版功能之后,我们就能够在应用中使用 `new Template, template->render('template.name.html')` 来进行页面结果的渲染了。 ### 简单实现主要逻辑 接下来,我们来实现“微博”的主要流程逻辑,大概 130 行左右的代码就能够搞定: ```php data = $this->loadData($page); ob_start(); $tpl->render('main.html'); ob_end_flush(); $end_time = microtime(true); echo "\n"; } else { $content = trim($_POST['content']); if (strlen($content) == 0) { echo ERROR_IS_EMPTY; exit; } $content = (string) filter_var($content, FILTER_SANITIZE_SPECIAL_CHARS); $this->postWhisper($content); } } private function postWhisper($content) { $date = date('Y-m-d g:i:s A'); $filename = DATA_DIR . DIRECTORY_SEPARATOR . date('YmdHis') . ".txt"; $file = fopen($filename, "w+"); $content = $date . "\n" . $content . "\n"; fwrite($file, $content); fclose($file); header("location: /"); } private function loadData($page) { $result = [ 'whispers' => [], 'pagination' => ['hide' => true], ]; $files = []; if ($handle = @opendir(DATA_DIR)) { while ($file = readdir($handle)) { if (!is_dir($file)) { $files[] = $file; } } } rsort($files); $total = sizeof($files); if ($total == 0) { return $result; } $page = $page - 1; $start = $page * WHISPER_PER_PAGE; if (($start + WHISPER_PER_PAGE) > $total) { $last = $total; } else { $last = $start + WHISPER_PER_PAGE; } for ($i = $start; $i < $last; $i++) { $raw = file(DATA_DIR . DIRECTORY_SEPARATOR . $files[$i]); $date = trim($raw[0]); unset($raw[0]); $content = ""; foreach ($raw as $value) { $content .= $value; } $data = array( 'date' => $date, 'content' => $content, ); $result['whispers'][] = $data; } $result['pagination'] = $this->getPagination($start, $last, $page, $total); return $result; } private function getPagination($start, $last, $page, $total) { if ($total <= WHISPER_PER_PAGE) { return ['hide' => true]; } $page = $page + 1; $next = 0; $prev = 0; if ($start == 0) { if ($last < $total) { $next = $page + 1; } } else { if ($last < $total) { $next = $page + 1; $prev = $page - 1; } else { $prev = $page - 1; } } return [ 'hide' => false, 'prev' => $prev, 'next' => $next, 'page' => $page, 'last' => ceil($total / 5), ]; } } new Whisper(); ``` ### 简单实现页面模版 完成主要程序实现之后,我们来实现页面模版,大概 120 行就能够搞定: ```html Whisper

Whisper

a simplest example.

# Post a Whisper

data['pagination']['hide']):?>

data['whispers'])>0):?>

# List data['pagination']['hide']):?> Page #data['pagination']['page']?> / data['pagination']['last']?>

    data['whispers'] as $whisper): ?>
``` ### 使用 PHP 官方镜像验证程序 为了方便后续的演示和性能对比,这里我们直接声明一些路径为 Nginx 容器的地址,所以当你看到后续 Apache 镜像中使用的路径,不必惊讶: ```php &1 | sed -n -e 's/^.*arguments: //p') \ CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \ echo $CONFARGS && \ ./configure --with-compat $CONFARGS --with-ld-opt="-Wl,-rpath,${PHP_LIB}" --add-dynamic-module=../${DEVEL_KIT_NAME} --add-dynamic-module=../${MODULE_NAME} && \ make modules ``` 完成构建之后,我们使用多阶段构建,制作最终的应用镜像就好了: ```bash FROM nginx:1.23.1-alpine LABEL MAINTAINER=soulteary@gmail.com COPY --from=Builder /usr/lib/libphp.so /usr/lib/ COPY --from=Builder /usr/lib/libargon2.so.1 /usr/lib/ COPY --from=Builder /lib/libz.so.1 /lib/ COPY --from=Builder /etc/php8/php.ini /etc/php8/ COPY --from=Builder /usr/src/nginx/objs/ndk_http_module.so /etc/nginx/modules/ COPY --from=Builder /usr/src/nginx/objs/ngx_http_php_module.so /etc/nginx/modules/ ENV PHP_LIB=/usr/lib COPY conf/nginx.conf /etc/nginx/ COPY app/index.php /usr/share/nginx/html/ COPY app/assets /usr/share/nginx/html/assets COPY app/templates /usr/share/nginx/html/templates RUN mkdir -p /usr/share/nginx/html/data && \ chown nginx:nginx /usr/share/nginx/html/data && \ chmod 777 /usr/share/nginx/html/data ``` 这个小节的完整代码,在这里可以找到: [soulteary/ngx-php-micro-blog/Dockerfile](https://github.com/soulteary/ngx-php-micro-blog/blob/main/Dockerfile)。使用 `docker build -t soulteary/ngx-php:8-microblog .`,完成基础镜像构建,我们将得到一个 12MB 左右的小巧的、包含了 Nginx PHP 模块的镜像。 ![小巧可爱的容器镜像](https://attachment.soulteary.com/2022/10/05/docker-image-size.png) 在完成了基础镜像构建之后,我们来进行程序的“改造”。 ## 将 PHP 程序适配 NGX PHP 环境 如果我们不修改任何代码,通过调整 `docker compose` 配置文件,切换容器镜像和挂载的文件,是可以让程序在我们新构建的 NGX-PHP 镜像中运行的。 ```yaml version: '3' services: talk: image: soulteary/ngx-php:8-microblog restart: always ports: - 8090:80 volumes: - ./app/data:/usr/share/nginx/html/data:rw - ./app/index.php:/usr/share/nginx/html/index.php:ro - ./app/templates:/usr/share/nginx/html/templates:ro - ./app/assets:/usr/share/nginx/html/assets:ro ``` 但是我们会得到一些报错,导致程序不能正常运行。 ### 解决变量、函数重复定义的问题 我们首先可能遇到的问题就是类似下面的报错,告诉我们重复声明了“某些内容”,比如常量: ```php Warning: Constant TEMPLATE_DIR already defined in /usr/share/nginx/html/index.php on line 4 ``` 或者重复声明了“某些类”: ```php Fatal error: Cannot declare class Template, because the name is already in use in /usr/share/nginx/html/index.php on line 18 ``` 出现这两个问题的原因,是因为 NGX PHP 模块中,“全局变量和静态变量”都是不安全的。 解决第一个问题,我们可以有两个方案,降低声明的作用域,或者加上一些防御性判断: ```php defined('TEMPLATE_DIR') or define('TEMPLATE_DIR', '/usr/share/nginx/html/templates'); ``` 解决第二个问题,我们只能够依赖添加判断来避免重复声明: ```php if (!class_exists('Template')) { class Template { // ... } } ``` 解决完毕上面两个问题,程序就能够正常展示界面了。 ### 解决参数获取不到的问题 虽然解决了上面的问题,程序能够正常展示,但是我们会发现提交任何内容,程序都不会有“正确的反应”,而 Nginx 日志中也没有任何错误信息。 出现这个问题的原因是,在 NGX PHP 环境下,PHP 获取用户提交数据的方式由 `$_GET` 和 `$_POST` 改为了 `ngx_query_args()` 和 `ngx_post_args()`。 为了解决这个问题,并且保持我们的程序依旧能够在官方 PHP 环境中运行、调试,可以实现一个简单的 `getArgs` 方法,让程序兼容不同的环境: ```php private function getArgs($key, $method) { $dataSource = null; $isNginxEnv = false; if ($method == 'GET') { if (function_exists('ngx_query_args')) { $dataSource = ngx_query_args(); $isNginxEnv = true; } else { $dataSource = $_GET; } } else { if (function_exists('ngx_post_args')) { $dataSource = ngx_post_args(); $isNginxEnv = true; } else { $dataSource = $_POST; } } if (!isset($dataSource[$key])) { return ""; } return $isNginxEnv ? trim(urldecode($dataSource[$key])) : trim($dataSource[$key]); } ``` 对应的,调整上文中程序获取用户输入数据的方法,就能够让程序正常的在 NGX PHP 容器中运行啦。 ### 最终应用程序 最终的应用程序,算上换行大概 220 行左右: ```php dir = $dir; } } public function render($file) { if (file_exists($this->dir . $file)) { include $this->dir . $file; } else { throw new Exception('no template file ' . $file . ' present in directory ' . $this->dir); } } public function __set($name, $value) { $this->vars[$name] = $value; } public function __get($name) { return $this->vars[$name]; } } } if (!class_exists('Whisper')) { class Whisper { private function getArgs($key, $method) { $dataSource = null; $isNginxEnv = false; if ($method == 'GET') { if (function_exists('ngx_query_args')) { $dataSource = ngx_query_args(); $isNginxEnv = true; } else { $dataSource = $_GET; } } else { if (function_exists('ngx_post_args')) { $dataSource = ngx_post_args(); $isNginxEnv = true; } else { $dataSource = $_POST; } } if (!isset($dataSource[$key])) { return ""; } return $isNginxEnv ? trim(urldecode($dataSource[$key])) : trim($dataSource[$key]); } private function redir($url) { if (function_exists('ngx_header_set')) { ngx_header_set("Location", $url); ngx_exit(NGX_HTTP_MOVED_TEMPORARILY); } else { header("Location: " . $url); } } public function __construct() { $content = $this->getArgs('content', 'POST'); if (empty($content)) { $start_time = microtime(true); $page = 1; $page = $this->getArgs('p', 'GET'); if (!empty($page)) { $page = (int) filter_var($page, FILTER_SANITIZE_NUMBER_INT); if ($page < 1) { $page = 1; } } else { $page = 1; } $tpl = new Template(); $tpl->data = $this->loadData($page); ob_start(); $tpl->render('main.html'); ob_end_flush(); $end_time = microtime(true); echo "\n"; } else { $content = htmlentities((string) filter_var($content, FILTER_SANITIZE_SPECIAL_CHARS)); $this->postWhisper($content); } } private function postWhisper($content) { $date = date('Y-m-d g:i:s A'); $filename = DATA_DIR . DIRECTORY_SEPARATOR . date('YmdHis') . ".txt"; $file = fopen($filename, "w+"); $content = $date . "\n" . $content . "\n"; fwrite($file, $content); fclose($file); $this->redir("/"); } private function loadData($page) { $result = [ 'whispers' => [], 'pagination' => ['hide' => true], ]; $files = []; if ($handle = @opendir(DATA_DIR)) { while ($file = readdir($handle)) { if (!is_dir($file)) { $files[] = $file; } } } rsort($files); $total = sizeof($files); if ($total == 0) { return $result; } $page = $page - 1; $start = $page * WHISPER_PER_PAGE; if (($start + WHISPER_PER_PAGE) > $total) { $last = $total; } else { $last = $start + WHISPER_PER_PAGE; } for ($i = $start; $i < $last; $i++) { $raw = file(DATA_DIR . DIRECTORY_SEPARATOR . $files[$i]); $date = trim($raw[0]); unset($raw[0]); $content = ""; foreach ($raw as $value) { $content .= $value; } $data = array( 'date' => $date, 'content' => html_entity_decode($content), ); $result['whispers'][] = $data; } $result['pagination'] = $this->getPagination($start, $last, $page, $total); return $result; } private function getPagination($start, $last, $page, $total) { if ($total <= WHISPER_PER_PAGE) { return ['hide' => true]; } $page = $page + 1; $next = 0; $prev = 0; if ($start == 0) { if ($last < $total) { $next = $page + 1; } } else { if ($last < $total) { $next = $page + 1; $prev = $page - 1; } else { $prev = $page - 1; } } return [ 'hide' => false, 'prev' => $prev, 'next' => $next, 'page' => $page, 'last' => ceil($total / 5), ]; } } } new Whisper(); ``` ## 简单的性能比较 除了相信相对中立的机构的测试结果之外,我们也可以自己进行应用性能测试,来验证 NGX-PHP 是否真的能够“降本增效”。 下面我们就用上面最终实现好的程序,分别在我们构建的 `soulteary/ngx-php:8` 镜像和 PHP 官方镜像 `php:8.1.10-apache-buster` 中进行简单的请求性能测试: 先使用开启 OPCACHE 之后的官方镜像(`php:8.1.10-apache-buster`),完成30s 的压力测试: ```bash wrk -t16 -c 100 -d 30s http://127.0.0.1:8090 Running 30s test @ http://127.0.0.1:8090 16 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 132.92ms 158.49ms 1.98s 86.67% Req/Sec 54.38 56.83 670.00 94.88% 22603 requests in 30.08s, 49.40MB read Socket errors: connect 0, read 0, write 0, timeout 112 Requests/sec: 751.53 Transfer/sec: 1.64MB ``` 接着,使用我们构建好的 NGX PHP 镜像,在不开启缓存的情况下进行测试: ```bash wrk -t16 -c 100 -d 30s http://127.0.0.1:8090 Running 30s test @ http://127.0.0.1:8090 16 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 94.01ms 15.94ms 431.01ms 81.62% Req/Sec 64.02 11.33 148.00 74.26% 30715 requests in 30.09s, 65.03MB read Requests/sec: 1020.65 Transfer/sec: 2.16MB ``` 可以看到,性能提升还是比较明显的。 ## 最后 好了,关于 NGX PHP 的第一篇文章就聊到这里吧。关于更多的细节,或许后面有机会,我会再写一两篇文章进行分享。 --EOF